<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>贪吃蛇</title>
    <style>
        html {
            height: 100%;
        }
        body {
            margin: 0;
            height: 100%;
            display: flex;
            align-items: center;
            justify-content: center;
        }

        #ground {
            border: 3px saddlebrown groove;
            background-image: url();
            background-size: cover;
        }

        #control {
            width: 500px;
            height: 300px;
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
            font-weight: bolder;
            font-size: xxx-large;
            color: #fff186;
            text-align: center;
            text-shadow: #d28f28 2px 2px 10px;
            background-color: rgba(237, 237, 227, 0.5);
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: space-around;
        }

        .btn {
            width: 120px;
            height: 40px;
            background-color: #efe7dd;
            color: #953712;
            border: #b78f70 2px groove;
            border-radius: 20px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: lightgoldenrodyellow 1px 1px 10px;
        }
        .btn:hover {
            background-color: #f9d56b;
        }
        .btn span {
            text-align: center;
            font-weight: bold;
            font-size: large;
            text-shadow: none;
        }

        .popup {
            margin: 0;
            padding: 0;
            position: absolute;
            left: 50%;
            top: 50%;
            transform: translate(-50%, -50%);
        }
        .popup::backdrop {
            background-color: rgba(0,0,0,0.2);
        }

        #dial {
            width: 500px;
            height: 250px;
            background-color: #F07575; /* 不支持渐变的浏览器回退方案 */
            background-image: linear-gradient(to left bottom, hsl(0, 80%, 70%), #bada55);
        }
        #dial form {
            height: 100%;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: space-evenly;
        }
        #dial form p {
            margin: 0;
            font-size: 40px;
            font-weight: bolder;
            color: #953712;
            text-shadow: #fff186 2px 2px 2px, white -2px -2px 2px;
        }
        #uname {
            width: 320px;
            line-height: 35px;
            font-size: large;
            text-align: center;
            background-color: lightcyan;
            outline: none;
        }
        #dial-btn-group {
            width: 100%;
            display: flex;
            align-items: center;
            justify-content: space-evenly;
        }

        #rankings {
            width: 500px;
            height: 300px;
            background-color: #fd9b11; /* 不支持渐变的浏览器回退方案 */
            background-image: linear-gradient(to right bottom, #fd9b11, #ffca51, #fef659);
        }
        #rankings p {
            margin: 10px 0 0 0;
            font-weight: bolder;
            font-size: xxx-large;
            color: #fff186;
            text-align: center;
            text-shadow: #d28f28 2px 2px 10px;
        }

        #records {
            height: 220px;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: space-evenly;
            overflow: auto;
        }
        .record-item {
            width: 100%;
            background-image: linear-gradient(to left top, #fd9b11, #ffca51, #fef659);
            display: flex;
            align-items: center;
            justify-content: space-around;
            font-weight: bold;
            font-size: large;
            color: #953712;
            text-align: center;
        }
        .record-item div {
            flex: 1
        }

        .close-icon {
            outline: none;
            cursor: pointer;
            width: 50px;
            height: 50px;
            position: absolute;
            right: 15px;
            top: 15px;
            border-color: transparent;
            border-radius: 50%;
            background-color: transparent;
            transition: background-color 0.5s;
            display: flex;
            align-items: center;
            justify-content: center;
        }
        .close-icon span {
            font-size: 50px;
            font-weight: bold;
            color: white;
        }
        .close-icon:hover {
            background-color: orangered;
        }
    </style>
</head>
<body>
    <div id="content">
        <canvas id="ground"></canvas>

        <div id="control">
            <div>贪吃蛇</div>
            <div></div>
            <div class="btn"><span>开始游戏</span></div>
            <div class="btn"><span>查看榜单</span></div>
        </div>

        <dialog id="dial" class="popup">
            <form method="dialog">
                <p>新 的 记 录</p>
                <input type="text" id="uname" placeholder="请输入您的昵称，长度不超过6个汉字">
                <div id="dial-btn-group">
                    <button class="btn" value="无名"><span>提交</span></button>
                    <button class="btn" value=""><span>放弃</span></button>
                </div>
            </form>
        </dialog>

        <dialog id="rankings" class="popup">
            <p>排 行 榜</p>
            <div id="records">
                <div class="record-item">
                    <div>名次</div>
                    <div>昵称</div>
                    <div>分数</div>
                    <div>日期</div>
                </div>
            </div>
            <form method="dialog">
                <button class="close-icon"><span>×</span></button>
            </form>
        </dialog>
    </div>
<script>
    (function main() {
        const ground = document.getElementById('ground'),
            control = document.getElementById('control'),
            [header, score, start, rank] = control.children,
            dialog = document.getElementById('dial'),
            inputName = document.getElementById('uname'),
            submitBtn = document.getElementById('dial-btn-group').children[0],
            rankings = document.getElementById('rankings'),
            recordList = document.getElementById('records'),
            contentBox = document.getElementById('content');

        const ctx = ground.getContext('2d'),
            len = 600,
            divides = 20,
            step = len / divides,
            radius = step / 3,
            fat = radius / 2,
            initSize = 5,
            initSpeed = 400,
            speedUpRate = 3 / 4,
            food = [0, 0],
            lightning = [0, 0],
            poison = [0, 0],
            maxCountdowns = [30, 20, 10],
            countdowns = maxCountdowns.slice(),
            PI = Math.PI,
            maxRecordNum = 3,
            maxNameByteLen = 12,
            rescale = Math.min(document.defaultView.innerWidth, document.defaultView.innerHeight) * 0.9 / len;

        const records = JSON.parse(localStorage.getItem('records')) || [];

        ground.setAttribute('width', len.toString());
        ground.setAttribute('height', len.toString());

        contentBox.style.transform = `scale(${rescale})`;
        dialog.style.transform = `translate(-50%, -50%) scale(${rescale})`;
        rankings.style.transform = `translate(-50%, -50%) scale(${rescale})`;

        class Utils {
            encode(pos) {
                return pos[0] + '&' + pos[1];
            }

            randomPos() {
                return [Math.floor(Math.random() * (divides-1)) + 1,
                    Math.floor(Math.random() * (divides-1)) + 1]
            }

            randomFood() {
                countdowns[0] = maxCountdowns[0];
                let [x, y] = this.randomPos();
                while(snake.querySet.has(this.encode([x, y]))
                || (x === poison[0] && y === poison[1]) || (x === lightning[0] && y === lightning[1])) {
                    [x, y] = this.randomPos();
                }
                food[0] = x;
                food[1] = y;
                draw.drawFood();
            }

            randomLightning() {
                countdowns[1] = maxCountdowns[1];
                let [x, y] = this.randomPos();
                while(snake.querySet.has(this.encode([x, y]))
                || (x === food[0] && y === food[1]) || (x === poison[0] && y === poison[1])) {
                    [x, y] = this.randomPos();
                }
                lightning[0] = x;
                lightning[1] = y;
                draw.drawLightning();
            }

            randomPoison() {
                countdowns[2] = maxCountdowns[2];
                let [x, y] = this.randomPos();
                while(snake.querySet.has(this.encode([x, y]))
                || (x === food[0] && y === food[1]) || (x === lightning[0] && y === lightning[1])) {
                    [x, y] = this.randomPos();
                }
                poison[0] = x;
                poison[1] = y;
                draw.drawPoison();
            }

            getRealPos(...args) {
                return args.map(v => v * step)
            }

            debounce(cb, delay) {
                let timer = null;
                return function (...args) {
                    if (timer) clearTimeout(timer);
                    timer = setTimeout(cb.bind(this, ...args), delay);
                }
            }

            gameStart() {
                snake = new Snake();
                utils.randomFood();
                utils.randomLightning();
                utils.randomPoison();
                snake.isMoving = true;
                control.style.display = 'none';
                document.body.onkeydown = snake.turn.bind(snake);
                document.body.ontouchstart = snake.startTouch.bind(snake);
                document.body.ontouchend = snake.endTouch.bind(snake);
                document.body.addEventListener('touchmove', (e) => {
                    if (snake.isMoving) e.preventDefault();
                }, {passive: false});
                maxCountdowns[0] = 30; maxCountdowns[1] = 20; maxCountdowns[2] = 10;
                countdowns.forEach((v, i) => countdowns[i] = maxCountdowns[i]);
                snake.move();
            }

            countScore() {
                return (snake.points.length - initSize)
                    << Math.round(Math.log2(snake.speed / initSpeed) / Math.log2(speedUpRate))
            }

            fail() {
                const res = this.countScore();
                header.innerText = '游戏结束';
                score.innerText = '分数：' + res;
                control.style.display = 'flex';
                const rl = records.length;
                if (rl < maxRecordNum || res > records[rl-1]['score']) {
                    dialog.showModal();
                    if (rl === maxRecordNum) records.pop();
                }
                document.body.onkeydown = null;
            }

            createRankings() {
                const frag = new DocumentFragment();
                for (let i=0; i<maxRecordNum; i++) {
                    const record = document.createElement('div');
                    record.classList.add('record-item');
                    for (let j=0; j<4; j++) {
                        record.append(document.createElement('div'));
                    }
                    frag.append(record);
                }
                recordList.append(frag);
                this.updateRankings();
            }

            limitByteLength(str, maxLen) {
                let count = 0;
                for (let i=0; i<str.length; i++) {
                    count += str.charCodeAt(i) > 255 ? 2 : 1;
                    if (count > maxLen) return str.slice(0, i);
                }
                return str
            }

            updateRecords(res, name) {
                if (name === '') return
                records.push({
                    name,
                    score: res,
                    time: Date.now()
                })
                records.sort((a, b) =>  b['score'] - a['score'] || a['time'] - b['time']);
                localStorage.setItem('records', JSON.stringify(records));
                this.updateRankings();
            }

            updateRankings() {
                records.forEach((record, i) => {
                    const item = recordList.children[++i];
                    item.children[0].innerText = (i).toString();
                    item.children[1].innerText = record['name'];
                    item.children[2].innerText = record['score'];
                    const T = new Date(record['time']);
                    item.children[3].innerText = `${T.getFullYear()}/${T.getMonth()+1}/${T.getDate()}`
                })
            }
        }

        class Draw {
            drawFood() {
                const [x, y] = utils.getRealPos(...food);
                ctx.beginPath();
                ctx.fillStyle = 'rgba(224, 64, 39, 1)';
                ctx.arc(x - radius / 3, y, radius, 0, PI * 2);
                ctx.arc(x + radius / 3, y, radius, 0, PI * 2);
                ctx.fill();
            }

            drawLightning() {
                const [x, y] = utils.getRealPos(...lightning);
                ctx.fillStyle = 'yellow';
                ctx.beginPath();
                const m = radius * 1.5,
                    n = m / 3;
                ctx.moveTo(x, y - m);
                ctx.lineTo(x, y - n);
                ctx.lineTo(x + m, y - n);
                ctx.lineTo(x, y + m);
                ctx.lineTo(x, y + n);
                ctx.lineTo(x - m, y + n);
                ctx.fill();
            }

            drawPoison() {
                const [x, y] = utils.getRealPos(...poison),
                    r = radius * 1.5;
                ctx.fillStyle = 'purple';
                ctx.beginPath();
                ctx.arc(x, y, r, PI, PI * 2);
                ctx.fill();
                ctx.fillStyle = 'white';
                ctx.beginPath();
                ctx.fillRect(x - r / 2, y, r, r);
            }

            drawHead(x, y, direction) {
                [x, y] = utils.getRealPos(x, y);
                ctx.fillStyle = 'rgba(16, 113, 10, 1)';
                ctx.beginPath();
                ctx.arc(x, y, radius, PI/4 + PI/2*direction, -PI/4 + PI/2*direction);
                ctx.lineTo(x, y);
                ctx.fill();
            }

            drawTail(preX, preY, x, y) {
                [preX, preY, x, y] = utils.getRealPos(preX, preY, x, y);
                let pre1, pre2;
                if (preX !== x) {
                    pre1 = [preX, y - fat];
                    pre2 = [preX, y + fat];
                } else {
                    pre1 = [x - fat, preY];
                    pre2 = [x + fat, preY];
                }

                ctx.fillStyle = 'rgba(118, 255, 209, 0.8)';
                ctx.beginPath();
                ctx.moveTo(x, y);
                ctx.lineTo(pre1[0], pre1[1]);
                ctx.lineTo(pre2[0], pre2[1]);
                ctx.fill();
            }

            // 根据当前节点与前一节点的相对位置，确定构建矩形的输入参数
            getRectParams(preX, preY, x, y) {
                let sx, sy, sw, sh;
                if (preX > x) {
                    sx = x; sy = y - fat;
                    sw = step; sh = fat << 1;
                } else if (preY > y) {
                    sx = x - fat; sy = y;
                    sw = fat << 1; sh = step;
                } else if (preX < x) {
                    sx = preX; sy = preY - fat;
                    sw = step; sh = fat << 1;
                } else {
                    sx = preX - fat; sy = preY;
                    sw = fat << 1; sh = step;
                }
                return [sx, sy, sw, sh]
            }

            drawBody(preX, preY, x, y) {
                [preX, preY, x, y] = utils.getRealPos(preX, preY, x, y);

                const [sx, sy, sw, sh] = this.getRectParams(preX, preY, x, y);
                let d = 4;

                ctx.beginPath();
                // 蛇的中段
                ctx.fillStyle = 'rgba(16, 113, 10, 1)';
                ctx.fillRect(sx, sy, sw, sh);

                // 蛇的环节
                ctx.fillStyle = 'rgba(118, 255, 209, 0.8)';
                if (preX !== x) {
                    ctx.fillRect(sx, sy, sw / d, sh);
                    ctx.fillRect(sx + sw * (d - 1) / d, sy, sw / d, sh);
                } else {
                    ctx.fillRect(sx, sy, sw, sh / d);
                    ctx.fillRect(sx, sy + sh * (d - 1) / d, sw, sh / d);
                }
            }

            drawSnake(points, direction) {
                ctx.clearRect(0, 0, len, len);
                const size = points.length;
                for (let i=1; i<size-1; i++) {
                    this.drawBody(...points[i-1], ...points[i]);
                }

                this.drawHead(...points[0], direction);
                this.drawTail(...points[size-2], ...points[size-1]);
            }

            clearUnit(cx, cy) {
                [cx, cy] = utils.getRealPos(cx, cy);
                ctx.clearRect(cx - step / 2, cy - step / 2, step, step)
            }

            clearSegment(preX, preY, x, y) {
                ctx.clearRect(...this.getRectParams(...utils.getRealPos(preX, preY, x, y)));
            }

            updateSnake(newHead, oldHead, preOldHead, direction, preNewTail, newTail, oldTail) {
                this.clearUnit(...newHead);
                this.clearUnit(...oldHead);

                if (oldTail) {
                    this.clearSegment(...newTail, ...oldTail);
                    this.clearSegment(...preNewTail, ...newTail);
                    this.drawTail(...preNewTail, ...newTail);
                }

                this.drawBody(...newHead, ...oldHead);
                this.drawBody(...oldHead, ...preOldHead);
                this.drawHead(...newHead, direction);
            }
        }

        class Snake {
            constructor() {
                this.direction = 0;
                this.nextDirection = 0;
                this.speed = initSpeed;
                this.isMoving = false;
                this.touchX = 0;
                this.touchY = 0;

                const middle = divides >> 1;
                this.points = [];
                for (let i=0; i<initSize; i++) {
                    this.points.push([middle + (initSize >> 1) - i, middle])
                }
                this.querySet = new Set(this.points.map(pos => utils.encode(pos)));
                draw.drawSnake(this.points, this.direction);
            }

            move() {
                const [preHeadX, preHeadY] = this.points[0];
                let nxt;

                this.direction = this.nextDirection;
                switch (this.direction) {
                    case 0: nxt = [preHeadX + 1, preHeadY]; break;
                    case 1: nxt = [preHeadX, preHeadY + 1]; break;
                    case 2: nxt = [preHeadX - 1, preHeadY]; break;
                    case 3: nxt = [preHeadX, preHeadY - 1]; break;
                    default: throw new Error('Unexpected Direction');
                }

                let oldTail = null;
                if (nxt[0] !== food[0] || nxt[1] !== food[1]) {
                    oldTail = this.points.pop();
                    this.querySet.delete(utils.encode(oldTail));
                }

                // 检测是否撞到墙边或者自身，或吃到毒蘑菇
                if (nxt[0] === 0 || nxt[1] === 0 || nxt[0] === divides || nxt[1] === divides
                    || this.querySet.has(utils.encode(nxt))
                    || (nxt[0] === poison[0] && nxt[1] === poison[1])) {
                    this.isMoving = false;
                    if (oldTail) this.points.push([-1,-1]); // 用来加上被提前去除的尾部
                    utils.fail();
                    return
                }

                this.points.unshift(nxt);
                this.querySet.add(utils.encode(nxt));
                // draw.drawSnake(this.points, this.direction);
                draw.updateSnake(...this.points.slice(0,3), this.direction, ...this.points.slice(-2), oldTail);

                if (nxt[0] === food[0] && nxt[1] === food[1]) {
                    utils.randomFood();
                } else if (nxt[0] === lightning[0] && nxt[1] === lightning[1]) {
                    this.speed *= speedUpRate;
                    maxCountdowns.forEach((v, i) => maxCountdowns[i] = Math.round(v / speedUpRate));
                    utils.randomLightning();
                }

                countdowns.forEach((countdown, i) => {
                    if (--countdowns[i] === 0) {
                        switch (i) {
                            case 0: {
                                draw.clearUnit(...food);
                                utils.randomFood();
                                break;
                            }
                            case 1: {
                                draw.clearUnit(...lightning);
                                utils.randomLightning();
                                break;
                            }
                            case 2: {
                                draw.clearUnit(...poison);
                                utils.randomPoison();
                                break;
                            }
                        }
                    }
                })

                setTimeout(() => this.move(), this.speed);
            }

            turn(e) {
                if (this.isMoving) {
                    switch (e.key) {
                        case 'd': case 'ArrowRight': if (this.direction !== 2) this.nextDirection = 0; break;
                        case 's': case 'ArrowDown': if (this.direction !== 3) this.nextDirection = 1; break;
                        case 'a': case 'ArrowLeft': if (this.direction !== 0) this.nextDirection = 2; break;
                        case 'w': case 'ArrowUp': if (this.direction !== 1) this.nextDirection = 3; break;
                    }
                }
            }

            startTouch(e) {
                if (this.isMoving) {
                    e.preventDefault();
                    this.touchX = e.targetTouches[0].screenX;
                    this.touchY = e.targetTouches[0].screenY;
                }
            }

            endTouch(e) {
                if (this.isMoving) {
                    if (this.direction === 0 || this.direction === 2) {
                        if (e.changedTouches[0].screenY > this.touchY) this.nextDirection = 1;
                        else if (e.changedTouches[0].screenY < this.touchY) this.nextDirection = 3;
                    } else {
                        if (e.changedTouches[0].screenX > this.touchX) this.nextDirection = 0;
                        else if (e.changedTouches[0].screenX < this.touchX) this.nextDirection = 2;
                    }
                }
            }
        }

        const utils = new Utils(),
            draw = new Draw();
        let snake;

        start.onclick = utils.debounce(utils.gameStart, 200);

        inputName.oninput = () => inputName.value = utils.limitByteLength(inputName.value, maxNameByteLen);
        inputName.onchange = () => submitBtn.value = inputName.value;
        dialog.onclose = () => utils.updateRecords(utils.countScore(), dialog.returnValue);

        rank.onclick = utils.debounce(() => rankings.showModal(), 200);
        utils.createRankings();
    })()
</script>
</body>
</html>